<script setup lang="ts">
import type { Vector2 } from '@vueuse/gesture'
import { useGesture } from '@vueuse/gesture'
import { useReducedMotion } from '@vueuse/motion'
import type { mastodon } from 'masto'

const { media = [] } = defineProps<{
  media?: mastodon.v1.MediaAttachment[]
}>()

const emit = defineEmits<{
  (event: 'close'): void
}>()

const modelValue = defineModel<number>({ required: true })

const slideGap = 20
const doubleTapThreshold = 250

const view = ref()
const slider = ref()
const slide = ref()
const image = ref()

const reduceMotion = import.meta.server ? ref(false) : useReducedMotion()
const isInitialScrollDone = useTimeout(350)
const canAnimate = computed(() => isInitialScrollDone.value && !reduceMotion.value)

const scale = ref(1)
const x = ref(0)
const y = ref(0)

const isDragging = ref(false)
const isPinching = ref(false)

const maxZoomOut = ref(1)
const isZoomedIn = computed(() => scale.value > 1)

const enableAutoplay = usePreferences('enableAutoplay')

function goToFocusedSlide() {
  scale.value = 1
  x.value = slide.value[modelValue.value].offsetLeft * scale.value
  y.value = 0
}

onMounted(() => {
  const slideGapAsScale = slideGap / view.value.clientWidth
  maxZoomOut.value = 1 - slideGapAsScale

  goToFocusedSlide()
})
watch(modelValue, goToFocusedSlide)

let lastOrigin = [0, 0]
let initialScale = 0
useGesture({
  onPinch({ first, initial: [initialDistance], movement: [deltaDistance], da: [distance], origin, touches }) {
    isPinching.value = true

    if (first) {
      initialScale = scale.value
    }
    else {
      if (touches === 0)
        handleMouseWheelZoom(initialScale, deltaDistance, origin)
      else
        handlePinchZoom(initialScale, initialDistance, distance, origin)
    }

    lastOrigin = origin
  },
  onPinchEnd() {
    isPinching.value = false
    isDragging.value = false

    if (!isZoomedIn.value)
      goToFocusedSlide()
  },
  onDrag({ movement, delta, pinching, tap, last, swipe, event, xy }) {
    event.preventDefault()

    if (pinching)
      return

    if (last)
      handleLastDrag(tap, swipe, movement, xy)
    else
      handleDrag(delta, movement)
  },
}, {
  domTarget: view,
  eventOptions: {
    passive: false,
  },
})

const shiftRestrictions = computed(() => {
  const focusedImage = image.value[modelValue.value]
  const focusedSlide = slide.value[modelValue.value]

  const scaledImageWidth = focusedImage.offsetWidth * scale.value
  const scaledHorizontalOverflow = scaledImageWidth / 2 - view.value.clientWidth / 2 + slideGap
  const horizontalOverflow = Math.max(0, scaledHorizontalOverflow / scale.value)

  const scaledImageHeight = focusedImage.offsetHeight * scale.value
  const scaledVerticalOverflow = scaledImageHeight / 2 - view.value.clientHeight / 2 + slideGap
  const verticalOverflow = Math.max(0, scaledVerticalOverflow / scale.value)

  return {
    left: focusedSlide.offsetLeft - horizontalOverflow,
    right: focusedSlide.offsetLeft + horizontalOverflow,
    top: focusedSlide.offsetTop - verticalOverflow,
    bottom: focusedSlide.offsetTop + verticalOverflow,
  }
})

function handlePinchZoom(initialScale: number, initialDistance: number, distance: number, [originX, originY]: Vector2) {
  scale.value = initialScale * (distance / initialDistance)
  scale.value = Math.max(maxZoomOut.value, scale.value)

  const deltaCenterX = originX - lastOrigin[0]
  const deltaCenterY = originY - lastOrigin[1]

  handleZoomDrag([deltaCenterX, deltaCenterY])
}

function handleMouseWheelZoom(initialScale: number, deltaDistance: number, [originX, originY]: Vector2) {
  scale.value = initialScale + (deltaDistance / 1000)
  scale.value = Math.max(maxZoomOut.value, scale.value)

  const deltaCenterX = lastOrigin[0] - originX
  const deltaCenterY = lastOrigin[1] - originY

  handleZoomDrag([deltaCenterX, deltaCenterY])
}

function handleLastDrag(tap: boolean, swipe: Vector2, movement: Vector2, position: Vector2) {
  isDragging.value = false

  if (tap)
    handleTap(position)
  else if (swipe[0] || swipe[1])
    handleSwipe(swipe, movement)
  else if (!isZoomedIn.value)
    slideToClosestSlide()
}

let lastTapAt = 0
function handleTap([positionX, positionY]: Vector2) {
  const now = Date.now()
  const isDoubleTap = now - lastTapAt < doubleTapThreshold
  lastTapAt = now

  if (!isDoubleTap)
    return

  if (isZoomedIn.value) {
    goToFocusedSlide()
  }
  else {
    const focusedSlideBounding = slide.value[modelValue.value].getBoundingClientRect()
    const slideCenterX = focusedSlideBounding.left + focusedSlideBounding.width / 2
    const slideCenterY = focusedSlideBounding.top + focusedSlideBounding.height / 2

    scale.value = 3
    x.value += positionX - slideCenterX
    y.value += positionY - slideCenterY
    restrictShiftToInsideSlide()
  }
}

function handleSwipe([horiz, vert]: Vector2, [movementX, movementY]: Vector2) {
  if (isZoomedIn.value || isPinching.value)
    return

  const isHorizontalDrag = Math.abs(movementX) >= Math.abs(movementY)

  if (isHorizontalDrag) {
    if (horiz === 1) // left
      modelValue.value = Math.max(0, modelValue.value - 1)
    if (horiz === -1) // right
      modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
  }
  else if (vert === 1 || vert === -1) {
    emit('close')
  }

  goToFocusedSlide()
}

function slideToClosestSlide() {
  const startOfFocusedSlide = slide.value[modelValue.value].offsetLeft * scale.value
  const slideWidth = slide.value[modelValue.value].offsetWidth * scale.value

  if (x.value > startOfFocusedSlide + slideWidth / 2)
    modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
  else if (x.value < startOfFocusedSlide - slideWidth / 2)
    modelValue.value = Math.max(0, modelValue.value - 1)

  goToFocusedSlide()
}

function handleDrag(delta: Vector2, movement: Vector2) {
  isDragging.value = true

  if (isZoomedIn.value)
    handleZoomDrag(delta)
  else
    handleSlideDrag(movement)
}

function handleZoomDrag([deltaX, deltaY]: Vector2) {
  x.value -= deltaX / scale.value
  y.value -= deltaY / scale.value

  restrictShiftToInsideSlide()
}

function handleSlideDrag([movementX, movementY]: Vector2) {
  goToFocusedSlide()

  if (Math.abs(movementY) > Math.abs(movementX)) // vertical movement is more than horizontal
    y.value -= movementY / scale.value
  else
    x.value -= movementX / scale.value

  if (media.length === 1)
    x.value = 0
}

function restrictShiftToInsideSlide() {
  x.value = Math.min(shiftRestrictions.value.right, Math.max(shiftRestrictions.value.left, x.value))
  y.value = Math.min(shiftRestrictions.value.bottom, Math.max(shiftRestrictions.value.top, y.value))
}

const sliderStyle = computed(() => {
  const style = {
    transform: `scale(${scale.value}) translate(${-x.value}px, ${-y.value}px)`,
    transition: 'none',
    gap: `${slideGap}px`,
  }

  if (canAnimate.value && !isDragging.value && !isPinching.value)
    style.transition = 'all 0.3s ease'

  return style
})

const imageStyle = computed(() => ({
  cursor: isDragging.value ? 'grabbing' : 'grab',
}))
</script>

<template>
  <div ref="view" flex flex-row h-full w-full overflow-hidden>
    <div ref="slider" :style="sliderStyle" w-full h-full flex items-center>
      <div
        v-for="item in media"
        :key="item.id"
        ref="slide"
        flex-shrink-0
        w-full
        h-full
        flex
        items-center
        justify-center
      >
        <component
          :is="item.type === 'gifv' ? 'video' : 'img'"
          ref="image"
          :autoplay="enableAutoplay"
          controls
          loop
          select-none
          max-w-full
          max-h-full
          :style="imageStyle"
          :draggable="false"
          :src="item.url || item.previewUrl"
          :alt="item.description || ''"
        />
      </div>
    </div>
  </div>
</template>
